昨天我們靠著釐清 providers、exports 與 imports 找回了消失的 provider。但在剛接觸 NestJS 時,有些人可能會有一個直覺想法:
「既然最後都是要拿 PostsService 的實例,我在 Controller 直接 new 一個出來用,不也一樣嗎?」
const postsService = new PostsService();
程式碼可以運行,方法也能順利呼叫。乍看之下,自己 new 和交給 NestJS 建立結果相同,但這其實踩進了另一個坑。
手動建立的實例就像是「逃離了容器管轄的黑戶」——它雖然能工作,卻完全脫離了 NestJS IoC 容器。生命週期鉤子(Lifecycle Hooks)、作用域(Scopes)、依賴自動組裝,甚至是單元測試的 Mock 覆寫,全都會在這一行 new 中失效。
今天我們要拆解的核心觀念是:原本應由 NestJS 管理的 Service,不該由使用端自行 new 出來。
這三個名詞常常一起出現,但負責的事情不同。

核心概念:物件所需的依賴由外部提供,而不是由物件自己建立。
以 PostsService 為例,它需要 PostIdGeneratorService 才能產生文章編號。我們不在內部自行初始化,而是透過建構子宣告需求:
export class PostsService {
constructor(
private readonly postIdGeneratorService: PostIdGeneratorService,
) {}
}
PostsService 只負責宣告「我要什麼」,這就是 DI。不過,光是這樣還不夠——到底由誰建立並傳入 PostIdGeneratorService?
核心概念:把物件建立的控制權交給外部框架
如果呼叫端自行組裝:
const generator = new PostIdGeneratorService();
const postsService = new PostsService(generator);
呼叫端必須決定:
IoC 則是把這些控制權交給框架,Controller 只需要宣告:
constructor(private readonly postsService: PostsService) {}
至於 PostsService 和它的依賴如何建立的細節,我們可以不用知道。
核心概念:實現控制反轉的工具或框架,負責建立物件、管理依賴關係,並在需要時注入依賴。
Nest 內建的 IoC 容器會在背後做這些:
當模組註冊:
@Module({
controllers: [PostsController],
providers: [PostIdGeneratorService, PostsService],
})
export class PostsModule {}
Nest 建立 PostsService 時,概念上等同於做了這件事:
const generator = container.getOrCreate(PostIdGeneratorService);
const postsService = new PostsService(generator);
採用 DI 容器並不代表依賴憑空消失了,而是框架在背後替我們解析並自動完成了傳入與組裝。
接下來用一個簡單的範例,看看「手動建立」和「容器管理」到底差在哪。
範例由兩個 Service 組成,PostsService 負責建立文章,而 PostIdGeneratorService 負責產生文章的流水編號。
先來看 PostIdGeneratorService:
@Injectable()
export class PostIdGeneratorService {
private sequence = 0;
next(): string {
this.sequence += 1;
return `post-${this.sequence}`;
}
}
PostsService 透過建構子取得編號產生器:
@Injectable()
export class PostsService implements OnModuleInit {
private initializedByNest = false;
constructor(
private readonly postIdGeneratorService: PostIdGeneratorService,
) {}
onModuleInit(): void {
this.initializedByNest = true;
}
create(title: string) {
return {
id: this.postIdGeneratorService.next(),
title,
initializedByNest: this.initializedByNest, // 用來觀察生命週期
};
}
}
在 NestJS 預設的 Singleton 作用域下,容器會在模組依賴解析完成後自動觸發 onModuleInit();而我們手動 new 出來的實例,則不會觸發這套流程。
接著在 Controller 建立兩個端點,分別用兩種方式取得 PostsService:
@Controller('posts')
export class PostsController {
// 手動建立 PostsService,並自行提供它需要的依賴
private readonly manualPostsService = new PostsService(
new PostIdGeneratorService(),
);
constructor(private readonly postsService: PostsService) {}
@Post('manual')
createManually(@Body() body: { title: string }) {
return this.manualPostsService.create(body.title);
}
@Post('injected')
createWithInjection(@Body() body: { title: string }) {
return this.postsService.create(body.title);
}
}
手動版本中,Controller 同時建立 PostsService 與它的依賴;Nest 注入版本則把這些建立與組裝工作交給容器。
啟動範例後,先呼叫手動建立的版本:
curl -X POST http://localhost:3000/posts/manual \
-H 'Content-Type: application/json' \
-d '{"title":"NestJS DI"}'
{
"id": "post-1",
"title": "NestJS DI",
"initializedByNest": false
}
再呼叫注入版本:
curl -X POST http://localhost:3000/posts/injected \
-H 'Content-Type: application/json' \
-d '{"title":"NestJS DI"}'
{
"id": "post-1",
"title": "NestJS DI",
"initializedByNest": true
}
兩個端點都從 post-1 開始,但 initializedByNest 一個是 false、一個是 true。雖然使用的是相同類別,實際上卻來自兩組彼此獨立的實例:
PostsController
├── Nest 注入的 PostsService
│ └── Nest 管理的 PostIdGeneratorService
└── 手動 new 的 PostsService
└── 手動 new 的 PostIdGeneratorService
new 讓物件圖脫離 IoC 容器為什麼兩個端點無法共用同一套狀態?根本原因在於 new PostsService(...) 這行程式碼,強制將物件的建立權從 Nest 容器手中奪了回來。
一旦實例被擺脫容器監管,就會產生這四個後果:
現在 PostsService 只有一個依賴,所以手動組裝看起來還不嚴重:
new PostsService(new PostIdGeneratorService());
如果未來再加入設定與 Repository:
constructor(
private readonly postIdGeneratorService: PostIdGeneratorService,
private readonly postsRepository: PostsRepository,
) {}
所有建立細節都會回到 Controller。Service 每增加一個依賴,Controller 就得跟著修改,原本應由容器封裝的組裝規則也散落進業務程式碼。
Nest 的 provider 預設使用 singleton scope。在同一個應用程式生命週期中,使用端通常會取得容器管理的同一個實例。
但 singleton 只約束容器管理的範圍。這行程式仍然可以建立額外實例:
new PostIdGeneratorService();
所以兩個端點各自擁有一份 sequence,才會同時從 post-1 開始。這不是 Nest 建立了兩個 singleton,而是其中一個實例根本不屬於 Nest。
onModuleInit() 會在所在模組的依賴解析完成後,由 Nest 對容器管理的物件呼叫。
如果你選擇手動初始化實例:
const service = new PostsService(new PostIdGeneratorService());
這只會執行 JavaScript 建構子,即使類別實作 OnModuleInit,也不會自動呼叫 onModuleInit()。
雖然你可以手動寫:
service.onModuleInit();
但這只是呼叫一個普通方法,不代表該實例已經進入 Nest 容器。
測試可以覆寫容器裡的 provider:
const moduleFixture = await Test.createTestingModule({
imports: [PostsModule],
})
.overrideProvider(PostIdGeneratorService)
.useValue({ next: () => 'test-post-id' })
.compile();
結果是:
| 端點 | 回傳 ID | 原因 |
|---|---|---|
/posts/injected |
test-post-id |
編號產生器由容器提供,成功被覆寫 |
/posts/manual |
post-1 |
Controller 自行建立依賴,TestingModule 不知道它存在 |
overrideProvider() 並沒有失效,它只會對容器知道的 provider 作用。
從流水號重複(單例失效)、生命週期沒觸發(Lifecycle 沉寂),到單元測試無法被 Mock,這些現象並不是幾個互不相干的 Bug。它們都是同一個根本原因造成的:手動 new 讓物件脫離了 IoC 容器。
要徹底解決這個問題,重點在回歸 DI 的本質:由模組註冊 provider,使用端只負責宣告需求。
@Module({
controllers: [PostsController],
providers: [PostIdGeneratorService, PostsService],
})
export class PostsModule {}
Controller 只保留建構子注入:
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
create(@Body() body: { title: string }) {
return this.postsService.create(body.title);
}
}
現在 Controller 不需要知道 PostsService 有幾個依賴、使用哪個具體實作,或何時執行 lifecycle hook。這些組裝規則都留在容器管理的邊界。
我們在實務開發中還是會遇到一些特殊情況,以下整理三種常見的情境與解法:
new?NestJS 沒有禁止 new。一般的 DTO、Entity、Value Object,或沒有外部依賴的區域性純工具,仍然可以依照設計自行建立。
但如果物件具有以下任一特徵,通常就適合交給容器:
overrideProvider() 替換。判斷重點不是名稱裡有沒有 Service,而是它是否需要容器提供的管理能力。
有時候我們需要根據設定選擇不同的實作,例如文章編號在某些環境使用 UUID,另一些環境使用流水號。這種情況可以使用 token 搭配 factory provider,把選擇實作的責任交給容器。
先定義共同介面與注入 token:
export const POST_ID_GENERATOR = Symbol('POST_ID_GENERATOR');
export interface PostIdGenerator {
next(): string;
}
再提供兩個實作:
@Injectable()
export class UuidPostIdGenerator implements PostIdGenerator {
next(): string {
return crypto.randomUUID();
}
}
@Injectable()
export class SequentialPostIdGenerator implements PostIdGenerator {
private sequence = 0;
next(): string {
this.sequence += 1;
return `post-${this.sequence}`;
}
}
接著把兩個實作都交給 Nest 管理,再由 factory 根據設定選擇:
@Module({
imports: [ConfigModule],
providers: [
UuidPostIdGenerator,
SequentialPostIdGenerator,
{
provide: POST_ID_GENERATOR,
inject: [
ConfigService,
UuidPostIdGenerator,
SequentialPostIdGenerator,
],
useFactory: (
configService: ConfigService,
uuidGenerator: UuidPostIdGenerator,
sequentialGenerator: SequentialPostIdGenerator,
): PostIdGenerator => {
return configService.get('POST_ID_TYPE') === 'uuid'
? uuidGenerator
: sequentialGenerator;
},
},
CustomProviderPostsService,
],
})
export class CustomProviderModule {}
使用端只需要注入 token,不必知道實際使用哪個實作:
@Injectable()
export class CustomProviderPostsService {
constructor(
@Inject(POST_ID_GENERATOR)
private readonly postIdGenerator: PostIdGenerator,
) {}
create(title: string) {
return {
id: this.postIdGenerator.next(),
title,
};
}
}
這裡的重點是:具體實作仍然由 Nest 建立與管理,factory 只負責選擇要使用哪一個。
因此,即使未來某個實作新增其他依賴,也不需要在 factory 裡手動 new 並組裝整棵依賴樹。
如果實作在應用啟動時就能決定,custom provider 通常已經足夠;只有需要在執行期間動態取得 provider 時,才需要考慮 ModuleRef。
ModuleRef若具體實作必須等到方法執行時才能決定,可以注入 ModuleRef,從容器取得已註冊的 provider:
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class PostPublisher {
constructor(private readonly moduleRef: ModuleRef) {}
publish(mode: 'draft' | 'public') {
const publisher =
mode === 'draft'
? this.moduleRef.get(DraftPostPublisher)
: this.moduleRef.get(PublicPostPublisher);
return publisher.publish();
}
}
get() 適合取得已註冊並存在於靜態模組範圍的 provider。若是 REQUEST 或 TRANSIENT scope,則需要使用非同步的 resolve():
const service = await this.moduleRef.resolve(ScopedService);
需要沿用目前請求的 request scope 時,還必須傳入對應的 context ID。這屬於較進階的作用域管理,這邊就不做更深入的說明了。
特別注意 ModuleRef 是特殊情況下的動態查找工具,不是一般建構子注入的替代品。濫用它會把依賴藏進方法內,讓類別的依賴關係變得不明顯;能在建構子明確宣告的依賴,仍應優先使用建構子注入。
@Injectable() 會攔截 new看到裝飾器,很容易以為只要類別標上 @Injectable(),任何建立方式都會自動連上 Nest:
@Injectable()
export class PostsService {}
但 @Injectable() 不會改寫 JavaScript 的 new,也不會在每次建立實例時通知容器。
它留下讓 Nest 解析建構子依賴所需的 metadata,表示這個類別可以交給 IoC 容器管理。真正讓 DI 生效仍有兩個前提:
因此:
const fromContainer = app.get(PostsService);
const createdManually = new PostsService(new PostIdGeneratorService());
兩者來自相同類別,卻不是相同實例,也不具有相同的管理方式。
new 會建立一棵容器外的物件圖:這些實例即使能正常運作,也不會自動套用 Nest 提供的作用域、lifecycle hook 與測試替換等管理能力。ModuleRef。@Injectable() 不會攔截 JavaScript 的 new:它提供 Nest 解析與管理類別所需的 metadata,但真正讓 DI 生效的關鍵,仍然是讓實例進入 Nest 容器的管理流程。